Java 内存区域
Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存分为若干个不同的数据区域 。这些区域都有各自的用途,以及创建和销毁的时间,有的区域遇着虚拟机进程的启动而存在,有的区域则依赖用户线程的启动和结束而建立和销毁。
Java 内存区域也称为 Java 运行时数据区域。其中包括:程序计数器 ,虚拟机栈 ,本地方法栈 ,Java 堆 ,方法区 ,运行时常量池 。此外还有一个概念也非常重要:直接内存 。
注意:程序计数器、虚拟机栈、本地方法栈属于每个线程私有的;堆和方法区属于线程共享访问的。
PC 计数器
定义:
- 程序计数器(
Program Counter Register
)是一块较小的内存空间。
作用:
- 当前线程所执行的字节码行号指示器。
- 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
阐述:
- 每个线程都有一个自己的
PC
计数器。 - 是线程私有的,生命周期与线程相同。
- 线程在执行
Java
方法时,PC
计数器值为正在执行的虚拟机字节码指令地址。 - 线程在执行
native
方法时,PC
计数器值为空(Undefined
) - 唯一一个在
Java
虚拟机规范中没有规定任何OutOfMemoryError
情况的区域。
1.2 Java 虚拟器栈
作用:
描述
Java
方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储 局部变量表、操作数栈、动态链接、方法出口 等信息。意义:
JVM
是基于栈的,每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
阐述:
- 平时常说的“栈”就是现在讲的虚拟机栈,或者说是虚拟机栈中的局部变量表部分。
- 当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法的运行期间不会改变局部变量表的大小。
- 在
Java
虚拟机中对这个区域规定了两种异常状况:- 当前线程请求的栈深度大于虚拟机所允许的深度,将会拋出
StackOverflowError
异常(在虚拟机栈不允许动态扩展的情况下) - 如果拓展时无法申请到足够的内存空间,将会抛出
OutOfMemoryError
异常。
- 当前线程请求的栈深度大于虚拟机所允许的深度,将会拋出
栈帧
栈帧中包括了四种元素:
局部变量表
局部变量表是一组变量值的存储空间,用于存储方法参数和局部变量。 在 Class
文件的方法表的 Code
属性的 max_locals
指定了该方法所需局部变量表的最大容量。
局部变量表在编译期间分配内存空间,可以存放编译期的各种变量类型:
- 基本数据类型 :
boolean
,byte
,char
,short
,int
,float
,long
,double
等8
种; - 对象引用类型 :
reference
(可能是指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置) - 返回地址类型 :
returnAddress
,返回地址的类型。
变量槽(Variable Slot
):
变量槽是局部变量表的最小单位,规定大小为
32
位。对于64
位的long
和double
变量而言,虚拟机会为其分配两个连续的Slot
空间。
操作数栈
操作数栈(Operand Stack
)也常称为操作栈,是一个后入先出栈。在 Class
文件的 Code
属性的 max_stacks
指定了执行过程中最大的栈深度。Java
虚拟机的解释执行引擎被称为基于栈的执行引擎 ,其中所指的栈就是指-操作数栈。
- 和局部变量表一样,操作数栈也是一个以
32
字长为单位的数组。 - 虚拟机在操作数栈中可存储的数据类型:
int
、long
、float
、double
、reference
和returnType
等类型 (对于byte
、short
以及char
类型的值在压入到操作数栈之前,也会被转换为int
)。 - 和局部变量表不同的是,它不是通过索引来访问,而是通过标准的栈操作 — 压栈和出栈来访问。比如,如果某个指令把一个值压入到操作数栈中,稍后另一个指令就可以弹出这个值来使用。
虚拟机把操作数栈作为它的工作区——大多数指令都要从这里弹出数据,执行运算,然后把结果压回操作数栈
例子:
1 | begin |
在这个字节码序列里,前两个指令
iload_0
和iload_1
将存储在局部变量表中索引为0
和1
的整数压入操作数栈中,其后iadd
指令从操作数栈中弹出那两个整数相加,再将结果压入操作数栈。第四条指令istore_2
则从操作数栈中弹出结果,并把它存储到局部变量表索引为2
的位置。
下图详细表述了这个过程中局部变量表和操作数栈的状态变化(图中没有使用的局部变量表和操作数栈区域以空白表示)。
动态链接
每个栈帧都包含一个指向运行时常量池中所属的方法引用,持有这个引用是为了支持方法调用过程中的动态链接。
Class
文件的常量池中存在有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用:
- 静态解析:一部分会在类加载阶段或第一次使用的时候转化为直接引用(如
final
、static
域等),称为静态解析, - 动态解析:另一部分将在每一次的运行期间转化为直接引用,称为动态链接。
方法返回地址
当一个方法开始执行以后,只有两种方法可以退出当前方法:
- 正常返回:当执行遇到返回指令,会将返回值传递给上层的方法调用者,这种退出的方式称为正常完成出口(
Normal Method Invocation Completion
),一般来说,调用者的PC
计数器可以作为返回地址。 - 异常返回:当执行遇到异常,并且当前方法体内没有得到处理,就会导致方法退出,此时是没有返回值的,称为异常完成出口(
Abrupt Method Invocation Completion
),返回地址要通过异常处理器表来确定。
当一个方法返回时,可能依次进行以下3
个操作:
- 恢复上层方法的局部变量表和操作数栈。
- 把返回值压入调用者栈帧的操作数栈。
- 将
PC
计数器的值指向下一条方法指令位置。
本地方法栈
作用:
- 本地方法栈和
Java
虚拟机栈发挥的作用非常相似,主要区别是:Java
虚拟机栈为虚拟机执行Java
方法服务,而本地方法栈为执行Native
方法服务(通常用 C 编写)。
阐述:
- 与虚拟机栈一样,本地方法栈也会抛出
StackOverflowError
和OutOfMemoryError
异常。
Java 堆
Java
堆是被所有线程共享的也是最大的一块内存区域,在虚拟机启动时创建。
作用:
- 存放对象实例,几乎所有的对象实例都在这里分配内存。
阐述:
Java
堆是垃圾收集器管理的主要区域,故也称 GC 堆。从内存回收的角度看:
Java
堆可分为两个不同的区域:新生代 (Young Generation
) 、老年代 (Old Generation
) 。新生代 (Young
) 又被划分为三个区域:一个Eden
区和两个Survivor
区 -From Survivor
区和To Survivor
区。
简要归纳:新的对象分配是首先放在年轻代 (
Young Generation
) 的Eden
区,Survivor
区作为Eden
区和Old
区的缓冲,在Survivor
区的对象经历若干次收集仍然存活的,就会被转移到老年代Old
中。从内存分配的角度看:
- 线程共享的
Java
堆可能划分出多个线程私有的分配缓冲区(TLAB)。
- 线程共享的
进一步划分的目的是为了更好的回收内存,或者更快的回收内存。
Java
堆可以处于物理上不连续的内存空间中,只要逻辑上是连续即可。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError
异常。
方法区
方法区和Java
堆一样,是所有线程共享的内存区域。
作用:
- 它用于存储类信息、常量、静态常量和即时编译后的代码等数据。
Java
虚拟机规范对方法区的限制非常宽松,除了和Java
堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。根据Java
虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError
异常。
运行时常量池
运行时常量池是方法区的一部分。
Class
文件中除了有类的版本、字段、方法和接口等描述信息外, 还有一类信息是常量池。
作用:
- 保存
Class
文件中描述的符号引用 以及翻译出来的直接引用。
特征:
- 具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是并非预置入
Class
文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的便是String
类的intern()
方法。
直接内存
直接内存不属于虚拟机运行时数据区的一部分,也不是Java
虚拟机规范中定义的内存区域。
Java NIO
允许Java
程序直接访问直接内存,通常直接内存的速度会优于Java堆内存。因此,对于读写频繁、性能要求高的场景,可以考虑使用直接内存。
阐述:
- 本机直接内存的分配不会受到
Java
堆大小的限制,但是既然是内存,肯定会受到本机总内存大小以及处理器寻址空间额限制。如果忽略直接内存,可能会导致各个内存区域的总和大于物理内存限制而出现OutOfMemoryError
。
对象
对象的创建
当虚拟机遇到一条
new
指令时,首先会去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。在类加载检查通过后,接下来虚拟机将为新生对象分配内存,对象所需的内存大小在类加载完成后便可完全确定。
为对象分配内存的方式有两种,选择哪种分配方式由
Java
堆是否规整决定,而Java
堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定:指针碰撞:
Java
堆中的内存绝对规整,所有用过的内存都在一边,空闲的内存在另一边,中间放着一个指针作为分界点的指示器,那分配内存就仅仅是把那个指针向空闲空间那一边挪动一段与对象大小相等的距离。空闲列表:
Java
堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,虚拟机维护一个列表,记录上哪些内存块可用的,在分配的时候从列表中找到一块足够大的内存空间划分给对象实例,并更新列表上的记录。
- 并发下对象创建的线程安全问题解决方案:
- 方案一:对分配内存空间的动作进行同步处理——实际上虚拟机采用
CAS
配上失败重试的方式保证更新操作的原始性。 - 方案二:把内存分配的动作划分在不同的空间之中进行,即每个线程在java堆中预先分配一小块内存,称为本地线程分配缓存(
Thread local Allocation Buffer,TLAB
)。每个线程在各自的TLAB
上分配,只有TLAB
用完并分配新的TLAB时才需要同步锁定。
内存分配完成后,虚拟机需要将分配到的内存空间都初始化零值(不包括对象头),如果使用
TLAB
,这一步可提前至TLAB
分配时进行。这一步保证对象的实例字段在不赋值的情况下可以直接使用。接下来要对对象进行必要的设置,如所属类的元信息、对象的哈希码、对象GC分带年龄 、线程持有的锁 、偏向线程ID 等信息。这些信息存储在对象头 (
Object Header
)。
上述工作完成以后,从虚拟机的角度来说,一个新的对象已经产生了。然而,从Java
程序的角度来说,对象创建才刚开始。在 new
指令之后会接着会执行 <init>
方法把对象按照程序员的意愿进行初始化。
对象的内存布局
HotSpot
虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header
)、实例数据(Instance Data
)和对齐填充(Padding
)。
对象头
在HotSpot
虚拟机中,对象头有两部分信息组成:运行时数据 和 类型指针。
- 运行时数据:用于存储对象自身运行时的数据,如哈希码(hashCode)、GC分带年龄、线程持有的锁、偏向线程ID 等信息。官方称为 “Mark Word”。
- 类型指针:即对象指向它的类元数据指针,虚拟机通过这个指针来确定这个对象是哪个对象的实例。
实例数据
是对象真正存储的有效信息,也就是程序代码中所定义的各种类型字段的内容。
对齐填充
对齐填充并不是必然存在的,仅仅起着占位符的作用。由于对象的大小必须是8字节的整数倍,而对象头部分正好是8字节的倍数(1倍或2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
对象的访问定位
Java
程序通过栈上的 reference
数据来操作堆上的数据。对象的访问方式取决于 JVM
的具体实现。目前主流的实现有:句柄 和 直接指针 两种。
句柄
Java
堆中划分出一块内存来作为句柄池,引用中存储对象的句柄地址,而句柄中包含了对象实例数据与对象类型数据各自的具体地址信息,具体构造如下图所示:
优势:引用存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象时非常普遍的行为)时只会改变句柄中的实例数据数据指针,而引用本身不需要改变。
直接指针
引用 中存储的直接就是对象地址,Java
堆对象内部的布局中必须考虑如何放置访问类型数据的相关信息。
优势:速度更快,节省了一次指针定位的时间开销。由于对象的访问在Java
中非常频繁,因此这类开销积少成多后也是非常可观的执行成本。